Skip to content

db/state, execution: reconcile caches when immutable files become visible - #23047

Draft
yperbasis wants to merge 1 commit into
mainfrom
yperbasis/statecache-publication-boundary
Draft

db/state, execution: reconcile caches when immutable files become visible#23047
yperbasis wants to merge 1 commit into
mainfrom
yperbasis/statecache-publication-boundary

Conversation

@yperbasis

@yperbasis yperbasis commented Aug 5, 2026

Copy link
Copy Markdown
Member

Closes #23028.

Problem

Immutable state files can become visible without passing through the commit paths that update StateCache and the commitment BranchCache. Cache entries loaded against the previous file generation can therefore survive the transition or be reinserted by an older read, causing execution to observe state that the newly visible files replaced.

Solution

Treat Aggregator.recalcVisibleFiles as the cache-coherence boundary. Before publishing a new visible-file bundle, the aggregator now reconciles both the bound StateCache and its commitment BranchCache. Binding also reconciles files that are already visible, and apply-only state caches participate because file publication can bypass authoritative applies even when reader fills are disabled.

StateCache keeps separate per-domain mutation, commit-coverage, and file frontiers. Files beyond observed commit coverage clear the cache, advance fill admission, and revoke older read views. Locally covered files are a no-op, including quiet domains, and the immutable-file floor survives state-version initialization.

BranchCache now distinguishes authoritative writes, quiet commit coverage, read fills, and adaptive preloads. Read-sourced entries carry both a state frontier and a file-visibility generation, so publication, visibility lowering, clear, or unwind cannot be followed by a delayed stale insertion. Authoritative writes behind an already-published frontier are rejected as well.

Performance

Normal file recalculations do not copy cache contents or retain commit write sets. Identical commitment visibility returns before taking cache locks, and locally built file ranges do not clear either cache. Reconciliation work runs only when the aggregator recalculates visible files; the hot state-cache read path is unchanged.

Validation

  • make test-short
  • affected package suites: execution/cache, execution/commitment, db/state, db/state/execctx, and execution/execmodule
  • focused race-detector coverage for publication, delayed fills, preloads, unwinds, and binding
  • go vet on the affected packages
  • full golangci-lint on the changed package trees
  • make erigon integration

pull Bot pushed a commit to Dustin4444/erigon that referenced this pull request Aug 13, 2026
…igontech#23005)

Fixes erigontech#22463.

## Summary

`StateCache` stores latest committed state. Unwind already made resident
dead-fork entries stale, but readers could add those values again from
an old or transient view: a transaction could survive or first bind
during unwind, staged unwind rows still existed in the backing database,
and read-ahead could fill concurrently.

This PR closes those windows by binding fill authority to both the
durable `PlainStateVersion` and the lifetime of the original `ReadView`.
Reads constrained by a staged unwind cannot fill, cache changes are
published only after the database commit, and read-ahead cannot cross
the unwind transition.

Snapshot and immutable-file publication are a separate coherence
boundary. erigontech#23028 still requires erigontech#23047 or an equivalent publication hook
and is not addressed here. Bounded speculative-unwind fills in the
separate commitment `BranchCache` pre-exist this PR and are tracked in
erigontech#23253.

## Review guide

Suggested order:

1. `execution/cache/view.go` and `state_cache.go`: fill admission and
publication.
2. `db/state/execctx/domain_shared.go`: transaction identity, bounded
reads, and commit/unwind integration.
3. `db/kv/membatchwithdb/memory_mutation.go` and
`db/state/temporal_mem_batch.go`: `PlainStateVersion` ownership and
monotonicity.
4. `execution/exec/blocks_read_ahead.go` and `execution/execmodule`:
read-ahead exclusion and lifecycle.

Focused regression tests sit beside each area.

## Correctness invariants

| Marker | Protects |
| --- | --- |
| `PlainStateVersion` | The durable state visible to a transaction |
| `readViewEpoch` | Whether a `ReadView` predates the latest unwind or
state discontinuity |
| Per-cache entry epoch and unwind floor | Whether a stored value
belongs to the retained fork |

Once the cache has a durable state version, an admission-gated state
fill is accepted only if the view has the published state version and
current epoch, publication is not in progress, its exact domain frontier
is not behind the cache, and the read has no staged-unwind step bound.
Content-addressed code-size fills do not need these state-view checks.

An ineligible view may still read cache hits; only its fill authority is
revoked. `WithFrontier` preserves the original epoch, so rebinding
cannot renew an old view. Stored entries remain O(1) to invalidate and
are discarded lazily. The three markers stay separate because durable
state, reader, and stored-entry lifetimes change at different
boundaries.

## Commit and unwind flow

1. Staging an unwind revokes existing views, invalidates stored entries,
and records the lowest staged boundary. Bounded reads cannot fill.
2. Flush advances `PlainStateVersion` exactly once with the domain
writes and collects cache updates without publishing them.
3. The database transaction commits.
4. Cache publication applies the complete batch. It repeats unwind
invalidation at the durable boundary, rejects delayed or out-of-order
versions, preserves entries after a continuous forward commit, and
clears them when continuity is unknown.

During publication, reads and view binding remain available, but fills
are disabled. A view bound during publication remains fill-inert until
explicitly rebound.

`MemoryMutation` resolves untouched sequences from its backing
transaction and flushes only changed sequence keys, so it cannot replay
an older state version. Pre-commit notifications receive the projected
version explicitly rather than deriving it from overlay sequence writes.
Notification ordering itself is unchanged and remains tracked in erigontech#23240.

One semaphore permit covers read-ahead warmup and unwind exclusion. A
warmup acquires it without blocking, so work requested while another
warmup or an unwind owns or waits for the permit is skipped rather than
queued. Unwind callers acquire it with their context and abort before
staging if cancellation wins. `updateForkChoice` and `SetHead` hold the
permit through unwind and publication; `ValidateChain` acquires it only
when it stages an unwind. Every FCU currently excludes warmup, including
FCUs that do not unwind; narrowing that scope is tracked in erigontech#23003.

## Performance

- The cache-hit path is unchanged.
- `View(nil)` adds one atomic load. Binding a fill-enabled `ReadView`
also takes `admissionMu.RLock` to check publication and state-version
eligibility; getters retain that view instead of paying the binding cost
per key.
- Normal getters reuse the `SharedDomains` transaction's memoized state
version. A different transaction resolves its version at initial
binding. If that resolution temporarily fails, later cache misses retry
it; each retry is local to that miss.
- Unwind invalidation remains O(1), with no cache scan or diff replay.
- Each accepted warmup performs one uncontended semaphore acquisition.
Rejected warmups do not start a goroutine, and the gate is never touched
per key.

## Validation

Regression tests cover old and newly bound views across every unwind
phase, bounded state and code-hash reads, delayed publications,
memory-overlay state versions, read-ahead exclusion and cancellation,
and valid forward fills.

---------

Co-authored-by: Alexey Sharov <askalexsharov@gmail.com>
@yperbasis yperbasis added this to the 3.7.0 milestone Aug 17, 2026
@yperbasis
yperbasis force-pushed the yperbasis/statecache-publication-boundary branch from 0566925 to 8c05677 Compare August 17, 2026 14:20
@yperbasis
yperbasis force-pushed the yperbasis/statecache-publication-boundary branch from 8c05677 to a090b25 Compare August 17, 2026 14:26
@yperbasis yperbasis changed the title db/state, execution: reconcile StateCache and BranchCache at the file-publication boundary db/state, execution: reconcile caches when immutable files become visible Aug 17, 2026
@yperbasis
yperbasis changed the base branch from yperbasis/statecache-followups to main August 17, 2026 14:26
@yperbasis
yperbasis marked this pull request as ready for review August 17, 2026 14:27
@yperbasis
yperbasis marked this pull request as draft August 17, 2026 14:32
@yperbasis
yperbasis marked this pull request as ready for review August 17, 2026 14:33
@yperbasis
yperbasis marked this pull request as draft August 17, 2026 14:34
var branchView commitment.BranchCacheView
if domain == kv.CommitmentDomain && sd.branchCache != nil {
if cv, cStepU64, ok := sd.branchCache.Get(k); ok {
branchView = sd.branchCache.View()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks broken. View must be long-living - to provide consistent data view across all Cache uses.

Comment thread db/state/aggregator.go
}

// BindStateCache reconciles a cache with current and future file visibility.
func (a *Aggregator) BindStateCache(stateCache *cache.StateCache) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. reconciles - word has no meaning for me.
  2. if we have stateCache.View() which passed as a parameter to many funcs - then it's unclear for me why need bind Agg and StateCache. What Bind does? a.boundStateCache != stateCache
  3. I still don't understand - why high-level cache - must look inside files management. It's abstractions leak. We already have mechanic which guarantee consistent data view: tx object. All other "global atomic fields which need to invalidate sometime" - is anti-pattern.


coh coherence.Gen
// appliedEnd gates read-sourced writes. coveredEnd also tracks quiet commits
// so locally built files do not churn the cache.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how built files can churn cache?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

StateCache: fills can go stale when a snapshot download extends file visibility with never-applied state

2 participants